跳到主要内容

结构型模式-享元模式

享元模式

享元模式(Flyweight)是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

// 通过 FlyweightFactory 内部维护一个 cache
public class FlyweightFactory {
// 持有缓存:
private static final Map<String, Flyweight> cache = new HashMap<>();

// 静态工厂方法:
public static Flyweight getFlyweight(String name) {
String key = name;
// 先查找缓存:
Flyweight fx = cache.get(key);
if (fx == null) {
// 未找到,创建新对象:
System.out.println(String.format("create new Flyweight(%s)", name));
fx = new ConcreteFlyweight(name);
// 放入缓存:
cache.put(key, fx);
} else {
// 缓存中存在:
System.out.println(String.format("return cached Flyweight(%s)", fx.name));
}
return fx;
}
}
classDiagram Client *--> Context Context --> FlyweightFactory Context --> Flyweight FlyweightFactory o--> Flyweight Flyweight <|-- ConcreteFlyweight Flyweight <|-- UnsharedConcreteFlyweight Context: -uniqueState Context: -flyweight Context: +Context(repeatingState, uniqueState) Context: +operation() FlyweightFactory: -map< repeatingState, Flyweight > cache FlyweightFactory: +getFlyweight(repeatingState) Flyweight: -repeatingState Flyweight: +operation(uniqueState)

享元 Flyweight

享元 (Flyweight) 类包含原始对象中部分能在多个对象中共享的状态。 同一享元对象可在许多不同情景中使用。 享元中存储的状态被称为 “内在状态”。 传递给享元方法的状态 被称为 “外在状态”。

享元模式的内部状态一般就存在 ConcreteFlyweight 里面,这个一般是该实例的固有属性(一般就是 Map 的 key)。而外部状态则是那些不存储在对象里面的数据

@Override
public void sayName(User user) {
System.out.println(user.getName());
}

如上面的例子所示,这个 User 并不保存在 ConcreteFlyweight 里面,而是在调用方法时才传进来(所以把不共享的数据抽离出来就是外部状态)

情景 Context

情景 (Context) 类内部包含了外部状态(uniqueState)和 内部状态(repeatingState),当客户端调用的时候可以通过构造函数把这两种状态传递进去创建一个 Context 对象,然后交给 Context 来维护这两种状态,这样可以避免客户端每次使用在操作 operation 方法时都需要手动传递外部状态(uniqueState),这个 Contex 不是必须的,可以只有 Flyweight Factory

// 这里 Context 的 +Context(repeatingState, uniqueState) 构造方法如下
this.uniqueState = uniqueState;
this.flyweight = factory.getFlyweight(repeatingState)

实际就是对 Flyweight 二层封装,使用户使用 Flyweight 时无需手动传递外部状态

Context context = new Context("内部状态", "外部状态");
context.operation();

// 如果直接使用 Flyweight 则是下面这样
FlyweightFactory f = new FlyweightFactory();
Flyweight fx = f.getFlyweight("内部状态");
fx.operation("外部状态");

享元工厂 Flyweight Factory

享元工厂(Flyweight Factory) 会对已有享元的缓存池进行管理(内部维护一个 HashMap)。 有了工厂后, 客户端就无需直接创建享元, 它们只需调用工厂并向其传递目标享元的一些内在状态即可。 工厂会根据参数在之前已创建的享元中进行查找, 如果找到满足条件的享元就将其返回; 如果没有找到就根据参数新建享元。

不需要共享的享元子类

这里的 UnsharedConcreteFlyweight 是指那些不需要共享的 Flyweight 子类,虽然大部分情况下都需要使用共享对象来降低内存的损耗,但是个别情况也可能需要用非共享的对象

public static void main(String[] args) {
FlyweightFactory f = new FlyweightFactory();
Flyweight fx = f.getFlyweight("X");
Flyweight fy = f.getFlyweight("Y");
Flyweight fy2 = f.getFlyweight("Y"); // 这个 fy2 就是上面那个副本

Flyweight uf = new UnsharedConcreteFlyweight(); // 非共享对象
}

游戏中的例子

下半部分转载自 游戏设计模式Design Patterns Revisited

迷雾散尽,露出了古朴庄严的森林。古老的铁杉,在头顶编成绿色穹顶。 阳光在树叶间破碎成金色顶棚。从树干间远眺,远处的森林渐渐隐去。

这是我们游戏开发者梦想的超凡场景,这样的场景通常由一个模式支撑着,它的名字低调至极:享元模式。

用几句话就能描述一片巨大的森林,但是在实时游戏中做这件事就完全是另外一件事了。 当屏幕上需要显示一整个森林时,图形程序员看到的是每秒需要送到 GPU 六十次的百万多边形。

我们讨论的是成千上万的树,每棵都由上千的多边形组成。 就算有足够的内存描述森林,渲染的过程中,CPU 到 GPU 的部分也太过繁忙了。

每棵树都有一系列与之相关的位:

  • 定义树干,树枝和树叶形状的多边形网格。
  • 树皮和树叶的纹理。
  • 在森林中树的位置和朝向。
  • 大小和色彩之类的调节参数,让每棵树都看起来与众不同。
class Tree
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};

这是一大堆数据,多边形网格和纹理体积非常大。 描述整个森林的对象在一帧的时间就交给 GPU 实在是太过了。 幸运的是,有一种老办法来处理它。

关键点在于,哪怕森林里有千千万万的树,它们大多数长得一模一样。 它们使用了相同的网格和纹理。 这意味着这些树的实例的大部分字段是一样的。

imagebf3b36ec26d0f0f3.png

我们可以通过显式地将对象切为两部分来更加明确地模拟。 第一,将树共有的数据拿出来分离到另一个类中:

class TreeModel
{
private:
Mesh mesh_;
Texture bark_;
Texture leaves_;
};

游戏只需要一个这种类, 因为没有必要在内存中把相同的网格和纹理重复一千遍。 游戏世界中每个树的实例只需有一个对这个共享 TreeModel 的引用。 留在 Tree 中的是那些实例相关的数据:

class Tree
{
private:
TreeModel* model_;

Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
};

可以将其想象成这样:

imagecbdfafa8cb6e6abd.png

把所有的东西都存在主存里没什么问题,但是这对渲染也毫无帮助。 在森林到屏幕上之前,它得先到 GPU。我们需要用显卡可以识别的方式共享数据。

一千个实例

为了减少需要推送到 GPU 的数据量,我们想把共享的数据——TreeModel——只发送一次。 然后,我们分别发送每个树独特的数据——位置,颜色,大小。 最后,我们告诉 GPU,“使用同一模型渲染每个实例”。

今日的图形接口和显卡正好支持这一点。 这些细节很繁琐且超出了这部书的范围,但是 Direct3D 和 OpenGL 都可以做 实例渲染。

在这些 API 中,你需要提供两部分数据流。 第一部分是一块需要渲染多次的共同数据——在例子中是树的网格和纹理。 第二部分是实例的列表以及绘制第一部分时需要使用的参数。 然后调用一次渲染,绘制整个森林。

地形系统的例子

这些树长出来的地方也需要在游戏中表示。 这里可能有草,泥土,丘陵,湖泊,河流,以及其它任何你可以想到的地形。 我们基于区块建立地表:世界的表面被划分为由微小区块组成的巨大网格。 每个区块都由一种地形覆盖。

每种地形类型都有一系列特性会影响游戏玩法:

  • 决定了玩家能够多快地穿过它的移动开销。
  • 表明能否用船穿过的水域标识。
  • 用来渲染它的纹理。

因为我们游戏程序员偏执于效率,我们不会在每个区块中保存这些状态。 相反,一个通用的方式是为每种地形使用一个枚举。

enum Terrain
{
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// 其他地形
};

然后,世界管理巨大的网格:

class World
{
private:
Terrain tiles_[WIDTH][HEIGHT];
};

为了获得区块的实际有用的数据,我们做了一些这样的事情:

int World::getMovementCost(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return 1;
case TERRAIN_HILL: return 3;
case TERRAIN_RIVER: return 2;
// 其他地形……
}
}

bool World::isWater(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return false;
case TERRAIN_HILL: return false;
case TERRAIN_RIVER: return true;
// 其他地形……
}
}

你知道我的意思了。这可行,但是我觉得很丑。 移动开销和水域标识是区块的数据,但在这里它们散布在代码中。 更糟的是,简单地形的数据被众多方法拆开了。 如果能够将这些包裹起来就好了。毕竟,那是我们设计对象的目的。

如果我们有实际的地形类就好了,像这样:

class Terrain
{
public:
Terrain(int movementCost,
bool isWater,
Texture texture)
: movementCost_(movementCost),
isWater_(isWater),
texture_(texture)
{}

int getMovementCost() const { return movementCost_; }
bool isWater() const { return isWater_; }
const Texture& getTexture() const { return texture_; }

private:
int movementCost_;
bool isWater_;
Texture texture_;
};

但是我们不想为每个区块都保存一个实例。 如果你看看这个类内部,你会发现里面实际上什么也没有, 唯一特别的是区块在哪里。 用享元的术语讲,区块的所有状态都是 “固有的” 或者说 “上下文无关的”。

鉴于此,我们没有必要保存多个同种地形类型。 地面上的草区块两两无异。 我们不用地形区块对象枚举构成世界网格,而是用 Terrain 对象指针组成网格:

class World
{
private:
Terrain* tiles_[WIDTH][HEIGHT];
};

每个相同地形的区块会指向相同的地形实例。

imagebb31f1fa889a4a91.png

由于地形实例在很多地方使用,如果你想要动态分配,它们的生命周期会有点复杂。 因此,我们直接在游戏世界中存储它们。

class World
{
public:
World()
: grassTerrain_(1, false, GRASS_TEXTURE),
hillTerrain_(3, false, HILL_TEXTURE),
riverTerrain_(2, true, RIVER_TEXTURE)
{}

private:
Terrain grassTerrain_;
Terrain hillTerrain_;
Terrain riverTerrain_;

// 其他代码……
};

然后我们可以像这样来描绘地面:

void World::generateTerrain()
{
// 将地面填满草皮.
for (int x = 0; x < WIDTH; x++)
{
for (int y = 0; y < HEIGHT; y++)
{
// 加入一些丘陵
if (random(10) == 0)
{
tiles_[x][y] = &hillTerrain_;
}
else
{
tiles_[x][y] = &grassTerrain_;
}
}
}

// 放置河流
int x = random(WIDTH);
for (int y = 0; y < HEIGHT; y++) {
tiles_[x][y] = &riverTerrain_;
}
}

现在不需要 World 中的方法来接触地形属性,我们可以直接暴露出 Terrain 对象。

const Terrain& World::getTile(int x, int y) const
{
return *tiles_[x][y];
}

用这种方式,World不再与各种地形的细节耦合。 如果你想要某一区块的属性,可直接从那个对象获得:

int cost = world.getTile(2, 3).getMovementCost();